在 Cloudflare Workers 上实现联机德州扑克:架构与踩坑记录

在 Cloudflare Workers 上实现联机德州扑克:架构与踩坑记录

上一篇写了「在 Cloudflare Workers 上部署 PeerJS 信令服务器」,解决了 WebRTC 建立连接的难题。但真正把联机德州扑克跑起来,比想象中曲折得多——尤其是 Cloudflare 免费计划下 Durable Objects 的休眠机制,差点让我放弃。

这篇文章记录联机版的整体架构,以及我实际踩过的几个坑和最终解决方案。

需求:博客里能双人联机打德州扑克

目标很简单:两个朋友打开同一个网页,输入同一个房间号,就能面对面打德州扑克。

  • 发牌、下注、翻牌、结算都在服务端完成(服务端权威,防止作弊,也保证双方状态一致)
  • 前端只负责渲染:玩家看到的牌局快照(snap)由服务端推送
  • 座位 0 / 座位 1,两人对战

整体架构

1
2
3
4
5
6
7
8
9
10
浏览器 A                         Cloudflare Worker                    浏览器 B
| | |
|-- wss://<worker>/poker?room --->| |
| | Durable Object (PokerRoom) |
| | ├ 管理两个 WebSocket 座位 |
| | ├ 服务端权威游戏逻辑(发牌/结算) |
| | └ 推送快照 snap 给双方 |
|<---------- snap ---------------|------- snap ----------------------->|
|----- act(加注/跟注/弃牌) ------>| |
| |<---------- act -------------------|
  • PokerRoom(Durable Object):整个游戏房间,持有两个玩家的 WebSocket 连接 + 全部游戏状态
  • PeerServer(Durable Object):保留的 PeerJS 信令逻辑(本文不展开,见上一篇)
  • 前端通过 wss://<worker>/poker?room=<房间号> 连接,房间号相同就进同一房间

为什么要拆成两个 DO——不是图省事,而是 Durable Object 的两个特性决定的:

PokerRoom PeerServer
职责 游戏状态 + 玩家 WebSocket WebRTC 信令转发(SDP/ICE)
实例数量 每房间一个(idFromName('poker:'+room)) 全局一个(idFromName('global'))
状态 game 整局状态 peers Map(peerId → ws)
  1. DO 实例是串行单点的:一个实例同时只能处理一件事。游戏逻辑(计算、广播)和信令转发(高频小消息)混在一个 DO 里会互相排队阻塞——某人在摊牌计算时,另一个人的 ICE 转发要干等。
  2. 扩展维度不同:房间天然平行——100 个房间 = 100 个实例互不干扰;信令全局单点就够。
  3. 复用既有资产:PeerServer 是上一篇已部署跑通的,直接引用旧命名空间保留,不动它的迁移记录。

核心代码结构(worker.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { DurableObject } from 'cloudflare:workers';

export class PokerRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.conns = [null, null]; // seat -> WebSocket
// ... 游戏状态
}

async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
server.addEventListener('message', (event) => this.handleMessage(server, event.data));
server.addEventListener('close', () => this.handleClose(server));
return new Response(null, { status: 101, webSocket: client });
}
// ...
}

关键设计点:

  1. 服务端权威:所有游戏逻辑(洗牌、发牌、规则判定、边池结算)都放在 Worker 里,前端只发指令、收快照。
  2. 快照隔离:buildSnap(seat) 给每个座位单独构建快照——只有自己能看到自己的手牌,对手的手牌是隐藏的,直到摊牌才 reveal。
  3. 心跳保活:客户端定时发 { t: 'ping' },服务端回 { t: 'pong' },防止空闲超时断连。
  4. 断线兜底:玩家断线后,如果轮到该玩家行动,60 秒内不重连就自动弃牌(scheduleTimeout),对局不会卡死。

部署前必配:DO 绑定与 migrations

代码写对了,但如果不在部署配置里声明 Durable Object,env.POKER_ROOM 会是 undefined,idFromName() 直接抛错——页面连房间都建不了。这一步是硬前提,配置全貌如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# wrangler.toml
name = "my-peerjs-server"
main = "worker.js"
compatibility_date = "2024-09-01"

[durable_objects]
bindings = [
# 新类:PokerRoom —— 首次部署必须走 migrations 创建
{ name = "POKER_ROOM", class_name = "PokerRoom" },
# 已存在的类:PeerServer —— 直接引用既有命名空间(配 namespace_id)
{ name = "PEER_SERVER", class_name = "PeerServer" },
]

[[migrations]]
tag = "poker-v1"
new_sqlite_classes = ["PokerRoom"]

三个关键点:

  1. bindings 声明类名:class_name 必须与 worker.js 里 export class PokerRoom 完全一致,否则部署报错。
  2. 新类必须配 migrations:Cloudflare 靠 [[migrations]] 才知道要新建一个 DO 类(SQLite 存储的 DO 用 new_sqlite_classes)。已经跑过的类(PeerServer)则不能出现在 migrations 里,否则会尝试重建。
  3. API 上传时 migrations 是对象不是数组(容易踩):用 Dashboard API 直接上传时,metadata 里 migrations 要写成 { tag, new_sqlite_classes } 单对象,而不是 [{ ... }] 数组——旧文档的数组写法在新 API 下会校验失败。这也是我在踩坑三里遇到编码问题之外,另一个和”上传”相关的坑。

房间路由(入口处):房间号通过 idFromName 哈希到固定的 DO 实例,相同房间号永远命中同一个 PokerRoom:

1
2
3
4
5
6
if (url.pathname.includes('/poker')) {
const room = url.searchParams.get('room') || 'default';
const id = env.POKER_ROOM.idFromName('poker:' + room); // 相同 room -> 同一实例
const stub = env.POKER_ROOM.get(id);
return stub.fetch(request); // 升级为 WebSocket 连接
}

完整部署链路:本地构建 → API 上传

文章开头的 worker.js 里其实有两个 DO 类(PokerRoom + PeerServer),它们分别来自两个独立开发过的源码。为避免手工复制粘贴出错,部署前用一个脚本把两者合并并生成上传物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
poker-worker.js(引擎 + PokerRoom)
│
▼ rebuild-merged.cjs 按标记位置拼接
current-worker.js 中的 PeerServer 类 ──► merged-worker.js(最终代码)
│
▼ 转 base64
merged-b64.txt
│
▼ build-upload-fn.cjs 生成上传函数
浏览器控制台执行:fetch PUT /workers/scripts/<脚本名>
│
▼
form-data 里带两份
├─ metadata: { main_module, bindings, migrations }
└─ worker.js: 合并后的源码

关键步骤的代码级含义:

① 合并:rebuild-merged.cjs 在 poker-worker.js 的”入口”标记处插入 PeerServer 类,拼出最终单文件——保证 export default 入口只有一个,且两个类都在:

1
const merged = poker.slice(0, idx) + peerClass + poker.slice(idx);

② 转 base64:合并后的源码转成 base64 字符串,因为等会儿要把它嵌进一个”上传函数”的字符串里,再贴到浏览器控制台执行——base64 可以安全地内嵌(无引号冲突、无转义问题)。

③ 上传(关键的 form-data 结构):通过 Cloudflare API 上传时,PUT 请求的 FormData 必须同时包含两个字段,缺一个都部署失败:

1
2
3
4
5
6
7
const form = new FormData();
// metadata:main_module + bindings + migrations(纯 JSON)
form.append('metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
// worker.js:上传的实际代码(MIME 必须是 module 类型)
form.append('worker.js',
new Blob([code], { type: 'application/javascript+module' }), 'worker.js');

为什么用浏览器控制台而非 wrangler CLI:本地没有配置 wrangler 的 API token,而 Cloudflare 的 API 上传端点可以在浏览器里直接调(配合账号的 API 读取权限),二选一即可,流程等价。

踩坑一:免费计划 DO 休眠,WebSocket 被 1012 强制断开

现象:A 创建房间后等待对手加入,如果超过约 30 秒没人操作,A 的页面就掉线了;重新进房间也连不上。

根因:Cloudflare 免费计划下,Durable Object 空闲约 30 秒后会休眠。休眠时,如果代码用的是普通 addEventListener 模式(而非官方推荐的 WebSocket Hibernation API),已建立的 WebSocket 会被以 1012 (Service Restart) 强制关闭。

1
2
3
// 这种写法,DO 休眠时连接会被 1012 杀掉:
server.addEventListener('message', ...);
server.addEventListener('close', ...);

验证方式:本地起服务分步复现(每步间隔 10~30 秒),发现两边 socket 都是被休眠干掉的——不是代码逻辑问题,是平台行为。

踩坑二:休眠导致”座位死引用”,B 永远等不到 A

现象:A 在线 3.5 秒后连接被休眠断开。B 加入同一房间时,显示”等待对方加入”……永远等不到。

根因:这是最阴险的坑——DO 休眠时,内存里的 conns[0] 还残留着 A 的 WebSocket 对象引用(一个死引用)。而 close 事件在休眠期间根本不会触发,所以:

  1. A 的连接实际已经断了,但 DO 不知道
  2. B 加入 → DO 检查座位 → 发现 conns[0] 还”占着” → 把 B 塞到座位 1 → 广播”等待对方加入”
  3. 但 A 已经消失了,永远不会有第二个人来 → 死锁

关键代码(有缺陷的版本逻辑上是这样):

1
2
3
4
5
if (this.conns[0] === null) {
// A 加入,座位 0
} else if (this.conns[1] === null) {
// B 加入,座位 1 —— 但如果 conns[0] 是死引用,这里就永远只能等一个消失的对手
}

教训:在 DO 里,”连接关闭”这个事件在休眠期间是不可靠的,不能依赖 close 事件来释放座位。

踩坑三:部署时 atob 的编码陷阱

现象:把 Worker 代码通过 Dashboard API 上传后,中文字符全部乱码。

根因:构建脚本把源码转 base64,再用 atob() 解码——但 atob() 返回的是 Latin-1 字符串,直接塞进 Blob 会被浏览器按 UTF-8 双重编码,中文就废了。

修复:解码后转成 Uint8Array 原始字节再交给 Blob:

1
2
3
4
5
// ❌ 中文会乱码
const code = atob(b64);

// ✅ 转原始字节,Blob 不再二次编码
const code = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));

牌型比较算法:从 7 张牌里选出最好的 5 张

摊牌时要判定谁赢,核心问题是:玩家 2 张底牌 + 5 张公共牌 = 7 张牌,选出其中最强的 5 张组合,再按德州扑克牌型大小排序。

5 张牌评估 eval5

先把 5 张牌按点数、花色分类,映射到 9 档牌型(数值越大越强):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var HAND_NAME = ['高牌', '一对', '两对', '三条', '顺子', '同花', '葫芦', '四条', '同花顺'];

function eval5(cards) {
var ranks = cardRanks(cards); // 点数降序:[14, 13, 11, 8, 3]
var flush = cards.every((c) => c.s === cards[0].s); // 是否同花
var sh = straightHigh(ranks); // 是否顺子,返回顺子最大点数
var g = groupsOf(ranks); // 按点数分组,多的在前:[{rank:14,count:2}, ...]

if (flush && sh) cat = 8; // 同花顺
else if (g[0].count===4) cat = 7; // 四条
else if (g[0].count===3 && g[1].count===2) cat = 6; // 葫芦
else if (flush) cat = 5; // 同花
else if (sh) cat = 4; // 顺子
else if (g[0].count===3) cat = 3; // 三条
else if (g[0].count===2 && g[1].count===2) cat = 2; // 两对
else if (g[0].count===2) cat = 1; // 一对
else cat = 0; // 高牌
}

两个容易被忽视的细节:

① 轮子顺(A-2-3-4-5):A 默认是 14,但 A 2 3 4 5 是最小的顺子。straightHigh 先查普通连续(u[j]-u[j+4]===4),再单独兜底 A-5 特例,返回 5:

1
2
3
4
5
6
7
8
9
10
function straightHigh(ranks) {
// 去重、降序后找连续 5 张
for (var j = 0; j <= u.length - 5; j++) {
if (u[j] - u[j + 4] === 4) return u[j];
}
// 轮子顺:A-2-3-4-5
if (u.indexOf(14) >= 0 && u.indexOf(5) >= 0 && u.indexOf(4) >= 0
&& u.indexOf(3) >= 0 && u.indexOf(2) >= 0) return 5;
return 0;
}

② groupsOf 的排序决定了踢脚比较:按「张数降序、点数降序」排,这样平局时逐位比较 tb 数组就是先比主要牌、再比踢脚。例如一对:[对子点数, 最高踢脚, 次高踢脚, 最低踢脚],两对:[大对, 小对, 踢脚],葫芦:[三条点数, 对子点数]。

两手牌比较 compareHands

先比牌型类别,类别相同逐位比 tb(缺位按 0),天然支持”平局即分池”:

1
2
3
4
5
6
7
8
function compareHands(a, b) {
if (a.cat !== b.cat) return a.cat - b.cat; // 先比类别
for (var i = 0; i < Math.max(a.tb.length, b.tb.length); i++) {
var x = a.tb[i] || 0, y = b.tb[i] || 0; // 再逐位比
if (x !== y) return x - y;
}
return 0; // 完全相等 -> 平局
}

7 选 5 bestOf7:暴力枚举

德州扑克的经典技巧是——不需要聪明算法,直接枚举。7 张牌删掉任意 2 张 = C(7,2) = 21 种 5 张组合,逐个 eval5 取最大:

1
2
3
4
5
6
7
8
9
function bestOf7(cards) {
for (var a = 0; a < 7; a++)
for (var b = a + 1; b < 7; b++) {
var five = cards.filter((_, i) => i !== a && i !== b); // 删 2 张
var h = eval5(five);
if (!best || compareHands(h, best) > 0) best = h; // 保留最大
}
return best;
}

每次摊牌最多 21 次 eval5,每次都是常数级操作——DO 单线程下摊牌耗时远小于 1ms,完全不需要引入 2+2 / 7-Card Hand Evaluator 这类查表算法。对 2 人小房间来说,简单暴力就是最优解。

下注轮状态机:preflop → flop → turn → river

牌局不是一串 if-else,而是一个状态机。每手牌经历 4 个街道,每个街道内玩家轮流行动,行动完一轮就推进到下一街道。

行动处理 processAction

玩家四种行动(fold / check / call / raise / allin),统一改玩家的 chips / bet / committed / folded / allIn / needsAction 六个字段:

1
2
3
4
5
6
7
8
9
10
// 加注的核心:结算差额、更新当前注额、必要时重新开放行动
else if (type === 'raise') {
var pay = Math.min(target - p.bet, p.chips); // 最多押上全部筹码
p.chips -= pay; p.bet += pay; p.committed += pay;
if (p.chips === 0) p.allIn = true;
var inc = newBet - g.currentBet;
if (inc >= g.minRaise) g.minRaise = inc; // 新最小加注额
g.currentBet = newBet;
reopenFor(g, seat); // 让其他未全下玩家重新行动
}

关键点 reopenFor:有人加注,其他已”跟平”的玩家必须重新获得行动权(否则可以免费等看牌),这就是”重新开放行动”:

1
2
3
4
5
6
7
function reopenFor(g, seat) {
for (var i = 0; i < g.players.length; i++) {
var p = g.players[i];
if (i === seat) continue;
if (!p.busted && !p.folded && !p.allIn) p.needsAction = true; // 重新轮到他们
}
}

街道推进 advance

每处理完一个行动就调用 advance,它是个 while(true) 循环,一口气把牌局推进到”需要玩家行动”或”结算”为止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function advance(g, logs) {
while (true) {
if (inHandCount(g) === 1) { endHandByFold(g, logs); return; } // 只剩一家 -> 弃牌获胜

if (roundComplete(g)) { // 一轮行动全部完成
if (g.street === 'river') { showdown(g, logs); return; } // 河牌结束 -> 摊牌
if (!canBet(g)) { // 没人能下注(全下),直接发完剩余牌
while (g.street !== 'river') dealStreet(g, logs);
showdown(g, logs); return;
}
dealStreet(g, logs); // 翻牌/转牌/河牌
g.currentBet = 0; // 新街道重置注额
for (p of players) p.bet = 0;
g.actorIndex = nextSeat(g, g.button);
continue; // 继续循环,进入下一街道
}
// 找下一个需要行动的人并返回,等待客户端消息
for (k...) { if (pl.needsAction) { g.actorIndex = idx; return; } }
}
}

advance 的巧妙之处:它把「弃牌获胜」「全下提前发牌」「街道推进」「轮转到下一个行动者」全部收敛到一个循环里,任何行动处理完调一次 advance,状态就绝对正确——这也是引擎能被 100 手随机对局测试验证的关键。

边池结算:全下时怎么分钱

2 人局也有全下(all-in)后筹码不对等的情况,底池必须按投入分层(main pot + side pot)。splitPots 从最低投入层开始逐层切分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function splitPots(g) {
var rem = players.map((p) => p.committed); // 每人已投入
while (true) {
var level = Infinity;
// 找当前最低的剩余投入层
for (k...) if (rem[k] > 0 && rem[k] < level) level = rem[k];
if (level === Infinity) break; // 全部切完
var size = 0;
for (m...) { var take = Math.min(rem[m], level); rem[m] -= take; size += take; }
// 只有投入 >= 该层且未弃牌的玩家有资格赢这层
var eligible = inHand.filter((p) => g.players[p.seat].committed >= level);
pots.push({ size: size, eligible: eligible });
}
return pots;
}

结算时逐池比较 eligible 里的手牌,赢家平分该池;分不平的余数按座位号从小到大的顺序每人多拿 1(share + (x < rest ? 1 : 0)),保证筹码总量严格守恒——测试里随机打 100 手,前后筹码总和必须完全一致。

1
2
3
4
var share = Math.floor(pot.size / winners.length);
var rest = pot.size % winners.length;
winners.sort((a, b) => a.seat - b.seat); // 余数按座位顺序分配
for (x...) { var got = share + (x < rest ? 1 : 0); winners[x].chips += got; }

安全性:为什么这套方案作弊成本极高

联机扑克最大的风险是玩家改客户端看对手手牌。这套架构从三个层面堵死:

  1. 快照隔离(最关键):buildSnap(forSeat) 按座位分别构建视图——对手的手牌永远只给 hasCards: true,点数花色一个都不下发;只有摊牌结束(!handInProgress)时才同时 reveal 双方底牌:

    1
    2
    // 座位 0 收到的快照:myHole 只有自己的,对手只有 hasCards
    { myHole: [自己两张牌], seats: [{ seat:1, hasCards: true, ... }] }

    就算玩家改浏览器拿到 snap,里面根本没有对手的牌——信息压根不出服务器。

  2. 服务端权威 + 行动校验:客户端只能发 { t: 'act', act, to } 指令,服务端先校验:座位号是否合法(seatOf 按 WebSocket 对象引用反查座位)、是否轮到自己(actorIndex === mySeat)、加注额是否达标(低于 currentBet + minRaise 自动降级为跟注/过牌)。客户端发任何越权指令都会被忽略。

  3. 房间边界 + 座位上限:idFromName('poker:' + room) 让房间互相隔离;每个 PokerRoom 只有 2 个座位,第三人 join 直接回 房间已满。房间号相同的人才能同局——没有全局匹配,也就不存在”陌生人乱入”。

  4. 断线兜底:玩家断线后若轮到其行动,scheduleTimeout 在 60 秒后自动弃牌,对局不会因一人消失而永久卡死。

测试脚本(test-poker-engine.cjs)专门验证了安全相关断言:200 手随机对局中,座位 0 收到的快照里从未出现座位 1 的手牌字符串(泄漏计数 = 0)。

解决方案:传统 WebSocket 模式 + 心跳保活

面对休眠问题,有两个方向:

方案 思路 代价
① WebSocket Hibernation API 官方推荐,DO 休眠时连接不断,消息到达自动唤醒 需要 ctx.acceptWebSocket() + webSocketMessage/webSocketClose 回调,代码结构调整大
② 传统模式 + 心跳 普通 addEventListener,用 ping/pong 让连接保持活跃 简单,但对空闲连接仍需处理休眠

我最终选了折中方案:保留传统 server.accept() + addEventListener 模式,但利用 DO 的一个特性——只要有连接打开,DO 就保持活跃不休眠,状态常驻内存,无需 storage 恢复。

配合客户端心跳(每 15~20 秒 ping 一次),实现了:

  • 对局中双方随时在线 → DO 活跃,不会休眠
  • 等待对手期间 → 心跳维持连接,A 不掉线
  • B 加入 → 消息到达,DO 正常处理,无需唤醒恢复

实测:A 在线等待、B 加入、双方正常开局、发牌/下注/翻牌交互全部正常。

最终方案的核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
export class PokerRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ctx = ctx;
this.conns = [null, null]; // seat -> WebSocket
}

// 传统 WebSocket 模式:只要有连接打开,DO 就保持活跃不休眠
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
server.addEventListener('message', (event) => this.handleMessage(server, event.data));
server.addEventListener('close', () => this.handleClose(server));
return new Response(null, { status: 101, webSocket: client });
}

handleMessage(ws, raw) {
let data;
try { data = JSON.parse(raw); } catch (e) { return; }

// 心跳保活:{ t:'ping' } -> { t:'pong' }
if (data.t === 'ping') {
this.safeSend(ws, { t: 'pong' });
return;
}

if (data.t === 'join') { /* 分配座位 0/1 */ }
if (data.t === 'act') { /* 处理下注动作(服务端权威) */ }
if (data.t === 'next') { /* 再来一局 */ }
}

// 断线后轮到该玩家,60 秒自动弃牌,对局不卡死
scheduleTimeout(seat) {
setTimeout(() => {
if (this.game && this.game.handInProgress
&& this.game.actorIndex === seat && !this.connected[seat]) {
// 自动弃牌
}
}, 60000);
}
}

前端心跳(示意):

1
2
3
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: 'ping' }));
}, 15000);

经验总结

  1. 先搞清楚平台行为再写代码。如果一开始就查清楚”免费计划 DO 30 秒休眠 + 非 Hibernation 模式会断连接”,能省下大量排查时间。
  2. 不要依赖休眠期间的事件。close 事件在 DO 休眠时可能不触发,释放资源(座位/连接引用)不能只靠它。
  3. 服务端权威是联机游戏的正解。所有逻辑放服务端,前端只渲染快照,既防作弊又保证同步,调试时问题也更好定位。
  4. 编码问题永远值得警惕。base64 → atob → Blob 这条链路上的字节级错误,排查起来非常隐蔽。
  5. DO 类不是写了就生效,还要声明。bindings(绑定类名)+ migrations(创建新类)缺一不可,API 上传时 migrations 是对象而非数组——部署配置和代码同样重要。
  6. 暴力枚举有时就是最优解。7 选 5 只需要 C(7,2)=21 次 eval5,DO 单线程下远小于 1ms,不值得引入复杂的查表求值器。
  7. 状态机收敛比散落的 if-else 可靠。所有行动都走 processAction 再统一 advance 推进,配合随机对局测试(筹码守恒、无卡死、无泄漏)能有效兜住边界情况。

在 Cloudflare Workers 上实现联机德州扑克:架构与踩坑记录
https://neoisconstantine-github-io.pages.dev/2026/08/11/在Cloudflare Workers上实现联机德州扑克/
作者
constantine
发布于
2026年8月12日
许可协议